FE 단위 테스트
JeongSeulho
2023년 12월 23일
0. 들어가며
FE에서의 단위 테스트와 예시를 정리
1. FE에서 단위 테스트란?
- 단위 테스트 : 앱에서 테스트 가능한
가장 작은 소프트웨어
를 실행해예상대로 동작하는지
확인하는 것 - FE에서의 단위 테스트 :
단일 함수 또는 단일 컴포넌트(Atomic 컴포넌트)
를 실행해결괏값 또는 상태(UI)
를 확인하는 것
또한, FE 테스트 개요에서 정리했듯이 내부 prop
, state
를 테스트하는 것이 아닌 UI
를 테스트한다.
2. 단위 테스트 대상 선정
단위 테스트는 다른 모듈에 대한 의존성이 거의 없으면서 독립적인 역할을 할 때 효과적이다.
- 공통 컴포넌트
(단, state
나 로직 없이 UI
만 그리는 컴포넌트는 단위 테스트 대상 아님, 스토리북과 같은 도구를 사용)
-
공통 유틸 함수
-
커스텀 훅
간단한 로직만 처리하는 컴포넌트는 상위 컴포넌트에서 함께 테스트 즉, 상위 컴포넌트의 테스트를 고려(단, 공통 컴포넌트처럼 독립적이라면 단위 테스트 수행)
2. 공통 컴포넌트 단위 테스트
(1) render 커스텀
앞으로 나오는 예시에서 사용하는 render
는 이 함수를 사용
// render.js
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// 컴포넌트에 비동기 동작이 있는 경우를 대비 async를 사용
export default async (component) => {
// userEvent 사용을 위한 설정,
// 필요시 setup({ logDOM: true }) 등 설정 가능
const user = userEvent.setup();
return {
user,
...render(component)
};
};
(2) 테스트 코드
it("className prop으로 설정한 css class가 적용된다.", async () => {
await render(<TextField className="my-class" />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
expect(textInput).toHaveClass("my-class");
});
describe("placeholder", () => {
it('기본 placeholder "텍스트를 입력해 주세요."가 노출된다.', async () => {
await render(<TextField />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
expect(textInput).toBeInTheDocument();
});
it("placeholder prop에 따라 placeholder가 변경된다.", async () => {
await render(<TextField placeholder="상품명을 입력해 주세요." />);
const textInput = screen.getByPlaceholderText("상품명을 입력해 주세요.");
expect(textInput).toBeInTheDocument();
});
});
it("텍스트를 입력하면 onChange prop으로 등록한 함수가 호출된다.", async () => {
const spy = vi.fn();
const { user } = await render(<TextField onChange={spy} />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
await user.type(textInput, "test");
expect(spy).toHaveBeenCalledWith("test");
});
it("엔터키를 입력하면 onEnter prop으로 등록한 함수가 호출된다.", async () => {
const spy = vi.fn();
const { user } = await render(<TextField onEnter={spy} />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
await user.type(textInput, "test{Enter}");
expect(spy).toHaveBeenCalledWith("test");
});
it("포커스가 활성화되면 onFocus prop으로 등록한 함수가 호출된다.", async () => {
const spy = vi.fn();
const { user } = await render(<TextField onFocus={spy} />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
await user.click(textInput);
expect(spy).toHaveBeenCalled();
});
it("포커스가 활성화되면 border 스타일이 추가된다.", async () => {
const { user } = await render(<TextField />);
const textInput = screen.getByPlaceholderText("텍스트를 입력해 주세요.");
await user.click(textInput);
expect(textInput).toHaveStyle({
borderWidth: 2,
borderColor: "rgb(25, 118, 210)"
});
});
3. 외부 모듈을 포함한 컴포넌트 단위 테스트
react-router-dom
의 useNavigate
를 사용하는 컴포넌트를 테스트하는 경우 특정 이벤트로 useNavigate
가 제대로 호출되었는지 확인하며 useNavigate
자체를 테스트하는 것은 아니다.
즉, 단위 테스트에서 외부 모듈에 대한 검증은 분리하며 특정 이벤트로 외부 모듈이 제대로 호출되었는지만 확인
(1) 모킹
모킹이란 실제 모듈을 대체하는 모의 모듈을 사용하는 것
- 장점
- 외부 모듈과 의존성을 끊어 테스트를 독립적으로 수행
- 단점
- 실제 모듈과 동일한 모듈 구현이 큰 비용이 들어감
- 신뢰성 낮음
(2) 모킹 방법
const navigateFn = vi.fn(); // 호출 여부 확인을 위한 스파이 함수
// react-router-dom 모듈을 다음과 같은 콜백 함수로 대체
vi.mock("react-router-dom", async () => {
// 실제 모듈을 그대로 가져옴
const original = await vi.importActual("react-router-dom");
return {
...original,
useNavigate: () => navigateFn // 그 중 검증해야할 useNavigate만 스파이 함수로 대체
};
});
it("Home으로 이동 버튼 클릭시 홈 경로로 이동하는 navigate가 실행된다", async () => {
const { user } = await render(<NotFoundPage />);
const button = await screen.getByRole("button", { name: "Home으로 이동" });
await user.click(button);
expect(navigateFn).toHaveBeenNthCalledWith(1, "/", { replace: true });
});
(3) 모킹 초기화
// setupTest.js
afterEach(() => {
// 모킹된 모듈의 구현은 유지 => 각 파일 내에서는 모킹된 모듈을 계속 사용
// 단, 모킹 히스토리(호출 횟수, 호출 인자 등)는 초기화 하여 독립적인 테스트 수행
vi.clearAllMocks();
});
afterAll(() => {
// 모킹된 모듈의 구현 자체를 초기화 => 테스트 파일간 모킹된 모듈은 별개
vi.resetAllMocks();
});
4. 리액트 훅 단위 테스트
(1) 테스트 대상 커스텀 훅
const useConfirmModal = (initialValue = false) => {
const [isModalOpened, setIsModalOpened] = useState(initialValue);
const toggleIsModalOpened = () => {
setIsModalOpened(!isModalOpened);
};
return {
toggleIsModalOpened,
isModalOpened
};
};
export default useConfirmModal;
테스트 하고자하는 기능
- 호출 시 initialValue 인자를 지정하지 않은 경우 isModalOpened의 초기값은 false
- 호출 시 initialValue 인자를 boolean 타입으로 지정한 경우 해당 값으로 isModalOpened의 초기값이 설정
- toggleIsModalOpened 함수를 호출하면 isModalOpened의 값이 반전
(2) 테스트 코드
it("호출 시 initialValue 인자를 지정하지 않는 경우 isModalOpened 상태가 false로 설정된다.", () => {
// 컴포넌트 렌더링 없이 훅만 테스트를 위한 renderHook 사용
const { result } = renderHook(useConfirmModal);
expect(result.current.isModalOpened).toBe(false);
});
it("호출 시 initialValue 인자를 boolean 값으로 지정하는 경우 해당 값으로 isModalOpened 상태가 설정된다.", () => {
const { result } = renderHook(() => useConfirmModal(true));
expect(result.current.isModalOpened).toBe(true);
});
it("훅의 toggleIsModalOpened()를 호출하면 isModalOpened 상태가 toggle된다.", () => {
const { result } = renderHook(useConfirmModal);
// act 함수를 사용하여 훅의 상태 업데이트를 JSDOM에 반영
act(() => {
result.current.toggleIsModalOpened();
});
expect(result.current.isModalOpened).toBe(true);
});
act
함수는 테스트 환경에서 컴포넌트의 렌더링과 상태 업데이트를 JSDOM
에 반영하기 위해 사용한다, 앞선 컴포넌트 테스트에서 render
, user-event
는 내부적으로 act
를 사용하고 있으므로 별도로 사용하지 않음
단, 위 처럼 state
를 업데이트하여 검증하는 경우 act
를 사용하여 state
반영이 필요
5. 타이머 관련 단위 테스트
(1) 테스트 대상 유틸 함수
export const debounce = (fn, wait) => {
let timeout = null;
return (...args) => {
const later = () => {
timeout = -1;
fn(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = window.setTimeout(later, wait);
};
};
테스트 하고자하는 기능
- 특정 시간이 지난 후에 함수가 실행
- 함수가 여러 번 호출되면 마지막 호출 시점으로부터 특정 시간이 지난 후에 함수가 실행
(2) 테스트 코드
describe("debounce", () => {
beforeEach(() => {
// 타이머 조작을 위해 사용
vi.useFakeTimers();
// 필요한 경우 시간을 고정하여 일관된 테스트 수행 가능
// vi.setSystemTime(new Date('2021-01-01T00:00:00.000Z'));
});
afterEach(() => {
// 테스트 끝나면 원래대로 돌려놓음
vi.useRealTimers();
});
it("특정 시간이 지난 후 함수가 호출된다.", () => {
const spy = vi.fn();
const debouncedFn = debounce(spy, 300);
debouncedFn();
// 300ms를 흐르도록 타이머 설정
vi.advanceTimersByTime(300);
expect(spy).toHaveBeenCalled();
});
it("연이어 호출해도 마지막 호출 기준으로 지정된 타이머 시간이 지난 경우에만 함수가 호출된다.", () => {
const spy = vi.fn();
const debouncedFn = debounce(spy, 300);
debouncedFn();
vi.advanceTimersByTime(200);
debouncedFn();
vi.advanceTimersByTime(100);
debouncedFn();
vi.advanceTimersByTime(200);
debouncedFn();
// 여기서만 마지막 호출 기준으로 300ms가 지난 상태이므로 실행
vi.advanceTimersByTime(300);
debouncedFn();
expect(spy).toHaveBeenCalledTimes(1);
});
});